tsl/compression: fix stale sparse-index entries after rebuild_sparse_index - #10324
Merged
Poroma-Banerjee merged 1 commit intoJul 27, 2026
Merged
Conversation
|
@akuzm, @dbeck: please review this pull request.
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Contributor
Author
|
At first glance, I don't see how failures in cagg_hierarchical_concurrent_refresh and cagg_concurrent_policy_register would be caused by my proposed changes. Those tests pass for me locally, and that is an independent feature, AFAIK. EDIT: Actually, I see them fail in other PRs as well, with the same outputs: 10315 10313. |
Poroma-Banerjee
approved these changes
Jul 27, 2026
Member
|
Please add a changelog entry. |
Member
|
Adding the changelog entry myself |
…index populate_sparse_index_columns() rewrites each compressed batch's sparse index metadata columns (min/max, first/last, bloom) in place via simple_table_tuple_update(), but discarded the TU_UpdateIndexes result that call computes instead of acting on it. When the rewrite wasn't HOT-eligible (typically because the row no longer fits on its original page), the btree index covering those columns never got a new entry for the row's new location, and the stale entry pointing at the superseded tuple was left behind. The stale entry usually goes unnoticed because PostgreSQL's HOT-chain "redirect" mechanism transparently follows it to the live tuple, as long as the chain stays on the same page. Once that chain is broken -- by ordinary heap pruning/vacuum, or whenever the rewrite must relocate to a different page -- an index or bitmap scan using the stale entry silently returns the wrong (or zero) rows for the affected batch, while a sequential scan still returns the correct data. This can surface as wrong query results for compressed hypertables after compress_index changes (e.g. via rebuild_sparse_index() or an ALTER TABLE compress_index change followed by recompression). Fix by opening the compressed table's indexes once per populate_sparse_index_columns() call and inserting the new entry whenever the update actually wrote a new tuple, mirroring the same open-index/simple-update/insert-index-entry sequence PostgreSQL's own CatalogTupleUpdate() uses for catalog tuples. This requires switching from simple_table_tuple_update() to simple_heap_update(): the former funnels the update through a TupleTableSlot and materializes a throwaway copy of the tuple inside heapam_tuple_update(), so the caller's own HeapTuple is never updated with the tuple's real post-update location or HOT status. simple_heap_update() operates directly on the passed HeapTuple, exactly as CatalogTupleUpdate() relies on -- consistent with the rest of this function, which already assumes a plain heap-backed relation (heap_modify_tuple, heap_deform_tuple). Adds a regression check that forces an index/bitmap scan after the rebuild_sparse_index() drop-and-restore dance in Test 10 and confirms per-device batch counts match a sequential scan; previously this would return fewer rows once the affected batch's index entry had gone stale.
Poroma-Banerjee
force-pushed
the
fix/rebuild-sparse-index-stale-indexes
branch
from
July 27, 2026 16:03
edee827 to
5f6d5a1
Compare
svenklemm
approved these changes
Jul 27, 2026
Merged
surister
pushed a commit
that referenced
this pull request
Jul 28, 2026
## 2.29.0 (2026-07-28) This release contains performance improvements and bug fixes since the 2.28.3 release. We recommend that you upgrade at the next available opportunity. **Release Highlights** * **Chunk exclusion for DML operations** drastically improves the performance of `UPDATE` and `DELETE` statements on hypertables. By acquiring exclusive locks only on the specific chunks being modified rather than the entire hypertable, this enhancement eliminates massive lock contention and keeps high-concurrency workloads running smoothly without unnecessary slowdowns. * Intelligent **row-by-row decompression** enables the query planner to decompress data row-by-row rather than in large batches when an operation prioritizes a fast initial response (such as queries with `LIMIT` clauses). This dramatically reduces memory overhead and query latency, ensuring lightning-fast performance when you only need to retrieve a small subset of records from your compressed hypertables. **Important: PostgreSQL 15 Support Removed** TimescaleDB 2.29.0 removes support for PostgreSQL 15. This release supports PostgreSQL 16, 17, and 18. If you are still running PostgreSQL 15, upgrade PostgreSQL before upgrading to TimescaleDB 2.29.0. **Backward-Incompatible Changes** * [#10041](#10041) Remove support for PostgreSQL 15 **Features** * [#9315](#9315) Speed up `DML` operations on hypertables by using the optimized TimescaleDB hypertable expansion code instead of the generic PostgreSQL inheritance hierarchy expansion * [#9534](#9534) Speed up expression evaluation in the columnar pipeline by caching common subexpressions * [#9684](#9684) Add `_timescaledb_functions.decompress_batch()` SQL function * [#9732](#9732) Speed up some queries with small `LIMIT` by switching to row-by-row query execution pipeline * [#9917](#9917) Decompress less data in `DML` on compressed hypertables by accounting for prepared statement parameters * [#9957](#9957) Add `compact_chunk()` function * [#10048](#10048) Support concurrent refresh policies on hierarchical continuous aggregates * [#10081](#10081) Add `samplerate` argument to `_timescaledb_functions.estimate_uncompressed_size()` * [#10100](#10100) Skip classifying compressed relations to speed up planning * [#10118](#10118) Don't track compressed relations as separate chunk * [#10119](#10119) Reduce memory usage of `INSERT` queries using direct compress and spanning multiple chunks * [#10163](#10163) Add a compaction policy for unordered chunks * [#10204](#10204) Don't create separate hypertable catalog entry for hypertables with compression * [#10217](#10217) Initial placeholder version of granular refresh API * [#10225](#10225) Add `config_merge` parameter to `alter_job()` for merging `jsonb` into the existing job configuration * [#10226](#10226) Add `recompress_unordered` columnstore policy option * [#10231](#10231) Use `regclass` for storing relation reference in chunk table * [#10237](#10237) Add helper functions for decoding hypertable status * [#10240](#10240) Add the `tsdb.direct_compress` storage parameter that allows enabling direct compress for a given hypertable independent of global settings * [#10266](#10266) Add `max_batches` to `compact_chunk()` * [#10299](#10299) Add `continuous_aggs_tenant_tracking` and `hypertable_cagg_settings` catalogs **Bugfixes** * [#10013](#10013) Make ownership error messages on continuous aggregates consistent * [#10052](#10052) Result of `MIN` / `MAX` aggregate functions in columnar aggregation pipeline possibly inconsistent with plain PostgreSQL result * [#10071](#10071) Prune the real-time branch of hierarchical continuous aggregates at any nesting depth * [#10143](#10143) Fix division by zero when planning `time_bucket` with zero width * [#10199](#10199) Fix `initial_start` handling in `build_job_info` * [#10213](#10213) Cache sort pathkeys per hypertable * [#10221](#10221) Fix incremental refresh skipping the last bucket * [#10278](#10278) Drop `job_errors` view in `bgw_job_stat_history` migration * [#10280](#10280) `RETURNING` clause returned no rows for `INSERT` using direct compress * [#10281](#10281) Disable direct compress when the destination table has an exclusion constraint so the constraint is still enforced * [#10282](#10282) Only count directly compressed rows toward the command tag when the `INSERT` sets it * [#10286](#10286) Propagate `VACUUM` on a chunk to the compressed relation when running on the chunk directly * [#10302](#10302) Fix useless-join removal and self-join elimination for hypertables * [#10313](#10313) Allow running `ALTER EXTENSION timescaledb UPDATE` inside a transaction block * [#10315](#10315) Fix overlap detection with running max * [#10324](#10324) Fix stale index entries after `rebuild_sparse_index()` on compressed chunks **GUCs** * `timescaledb.enable_hypertable_expansion_for_dml`: allow using the optimized TimescaleDB hypertable expansion code for `UPDATE` and `DELETE` instead of the generic PostgreSQL inheritance hierarchy expansion. On by default. **Thanks** * @FrancescEthon and @ManuelEthon for reporting the issue * @h0rn3t for reporting a problem with `VACUUM` not propagating to the compressed relation * @MaximeEthon for reporting an issue with prepared statement parameters in DML decompression * @proddata for reporting a problem when upgrading from 2.15.3 to 2.28.2 * @tureba for reporting and fixing stale sparse-index entries after rebuild * @viniciusrsouza for reporting an issue with hierarchical continuous aggregates
svenklemm
pushed a commit
that referenced
this pull request
Jul 28, 2026
This release contains performance improvements and bug fixes since the 2.28.3 release. We recommend that you upgrade at the next available opportunity. **Release Highlights** * **Chunk exclusion for DML operations** drastically improves the performance of `UPDATE` and `DELETE` statements on hypertables. By acquiring exclusive locks only on the specific chunks being modified rather than the entire hypertable, this enhancement eliminates massive lock contention and keeps high-concurrency workloads running smoothly without unnecessary slowdowns. * Intelligent **row-by-row decompression** enables the query planner to decompress data row-by-row rather than in large batches when an operation prioritizes a fast initial response (such as queries with `LIMIT` clauses). This dramatically reduces memory overhead and query latency, ensuring lightning-fast performance when you only need to retrieve a small subset of records from your compressed hypertables. **Important: PostgreSQL 15 Support Removed** TimescaleDB 2.29.0 removes support for PostgreSQL 15. This release supports PostgreSQL 16, 17, and 18. If you are still running PostgreSQL 15, upgrade PostgreSQL before upgrading to TimescaleDB 2.29.0. **Backward-Incompatible Changes** * [#10041](#10041) Remove support for PostgreSQL 15 **Features** * [#9315](#9315) Speed up `DML` operations on hypertables by using the optimized TimescaleDB hypertable expansion code instead of the generic PostgreSQL inheritance hierarchy expansion * [#9534](#9534) Speed up expression evaluation in the columnar pipeline by caching common subexpressions * [#9684](#9684) Add `_timescaledb_functions.decompress_batch()` SQL function * [#9732](#9732) Speed up some queries with small `LIMIT` by switching to row-by-row query execution pipeline * [#9917](#9917) Decompress less data in `DML` on compressed hypertables by accounting for prepared statement parameters * [#9957](#9957) Add `compact_chunk()` function * [#10048](#10048) Support concurrent refresh policies on hierarchical continuous aggregates * [#10081](#10081) Add `samplerate` argument to `_timescaledb_functions.estimate_uncompressed_size()` * [#10100](#10100) Skip classifying compressed relations to speed up planning * [#10118](#10118) Don't track compressed relations as separate chunk * [#10119](#10119) Reduce memory usage of `INSERT` queries using direct compress and spanning multiple chunks * [#10163](#10163) Add a compaction policy for unordered chunks * [#10204](#10204) Don't create separate hypertable catalog entry for hypertables with compression * [#10217](#10217) Initial placeholder version of granular refresh API * [#10225](#10225) Add `config_merge` parameter to `alter_job()` for merging `jsonb` into the existing job configuration * [#10226](#10226) Add `recompress_unordered` columnstore policy option * [#10231](#10231) Use `regclass` for storing relation reference in chunk table * [#10237](#10237) Add helper functions for decoding hypertable status * [#10240](#10240) Add the `tsdb.direct_compress` storage parameter that allows enabling direct compress for a given hypertable independent of global settings * [#10266](#10266) Add `max_batches` to `compact_chunk()` * [#10299](#10299) Add `continuous_aggs_tenant_tracking` and `hypertable_cagg_settings` catalogs **Bugfixes** * [#10013](#10013) Make ownership error messages on continuous aggregates consistent * [#10052](#10052) Result of `MIN` / `MAX` aggregate functions in columnar aggregation pipeline possibly inconsistent with plain PostgreSQL result * [#10071](#10071) Prune the real-time branch of hierarchical continuous aggregates at any nesting depth * [#10143](#10143) Fix division by zero when planning `time_bucket` with zero width * [#10199](#10199) Fix `initial_start` handling in `build_job_info` * [#10213](#10213) Cache sort pathkeys per hypertable * [#10221](#10221) Fix incremental refresh skipping the last bucket * [#10278](#10278) Drop `job_errors` view in `bgw_job_stat_history` migration * [#10280](#10280) `RETURNING` clause returned no rows for `INSERT` using direct compress * [#10281](#10281) Disable direct compress when the destination table has an exclusion constraint so the constraint is still enforced * [#10282](#10282) Only count directly compressed rows toward the command tag when the `INSERT` sets it * [#10286](#10286) Propagate `VACUUM` on a chunk to the compressed relation when running on the chunk directly * [#10302](#10302) Fix useless-join removal and self-join elimination for hypertables * [#10313](#10313) Allow running `ALTER EXTENSION timescaledb UPDATE` inside a transaction block * [#10315](#10315) Fix overlap detection with running max * [#10324](#10324) Fix stale index entries after `rebuild_sparse_index()` on compressed chunks **GUCs** * `timescaledb.enable_hypertable_expansion_for_dml`: allow using the optimized TimescaleDB hypertable expansion code for `UPDATE` and `DELETE` instead of the generic PostgreSQL inheritance hierarchy expansion. On by default. **Thanks** * @FrancescEthon and @ManuelEthon for reporting an issue with incremental refresh skipping the last bucket * @h0rn3t for reporting a problem with `VACUUM` not propagating to the * @igor2x for reporting an issue with locking during DML statements on hypertables compressed relation * @MaximeEthon for reporting an issue with prepared statement parameters in DML decompression * @proddata for reporting a problem when upgrading from 2.15.3 to 2.28.2 * @tureba for reporting and fixing stale sparse-index entries after rebuild * @viniciusrsouza for reporting an issue with hierarchical continuous aggregates
surister
pushed a commit
to surister/timescaledb
that referenced
this pull request
Jul 28, 2026
This release contains performance improvements and bug fixes since the 2.28.3 release. We recommend that you upgrade at the next available opportunity. **Release Highlights** * **Chunk exclusion for DML operations** drastically improves the performance of `UPDATE` and `DELETE` statements on hypertables. By acquiring exclusive locks only on the specific chunks being modified rather than the entire hypertable, this enhancement eliminates massive lock contention and keeps high-concurrency workloads running smoothly without unnecessary slowdowns. * Intelligent **row-by-row decompression** enables the query planner to decompress data row-by-row rather than in large batches when an operation prioritizes a fast initial response (such as queries with `LIMIT` clauses). This dramatically reduces memory overhead and query latency, ensuring lightning-fast performance when you only need to retrieve a small subset of records from your compressed hypertables. **Important: PostgreSQL 15 Support Removed** TimescaleDB 2.29.0 removes support for PostgreSQL 15. This release supports PostgreSQL 16, 17, and 18. If you are still running PostgreSQL 15, upgrade PostgreSQL before upgrading to TimescaleDB 2.29.0. **Backward-Incompatible Changes** * [timescale#10041](timescale#10041) Remove support for PostgreSQL 15 **Features** * [timescale#9315](timescale#9315) Speed up `DML` operations on hypertables by using the optimized TimescaleDB hypertable expansion code instead of the generic PostgreSQL inheritance hierarchy expansion * [timescale#9534](timescale#9534) Speed up expression evaluation in the columnar pipeline by caching common subexpressions * [timescale#9684](timescale#9684) Add `_timescaledb_functions.decompress_batch()` SQL function * [timescale#9732](timescale#9732) Speed up some queries with small `LIMIT` by switching to row-by-row query execution pipeline * [timescale#9917](timescale#9917) Decompress less data in `DML` on compressed hypertables by accounting for prepared statement parameters * [timescale#9957](timescale#9957) Add `compact_chunk()` function * [timescale#10048](timescale#10048) Support concurrent refresh policies on hierarchical continuous aggregates * [timescale#10081](timescale#10081) Add `samplerate` argument to `_timescaledb_functions.estimate_uncompressed_size()` * [timescale#10100](timescale#10100) Skip classifying compressed relations to speed up planning * [timescale#10118](timescale#10118) Don't track compressed relations as separate chunk * [timescale#10119](timescale#10119) Reduce memory usage of `INSERT` queries using direct compress and spanning multiple chunks * [timescale#10163](timescale#10163) Add a compaction policy for unordered chunks * [timescale#10204](timescale#10204) Don't create separate hypertable catalog entry for hypertables with compression * [timescale#10217](timescale#10217) Initial placeholder version of granular refresh API * [timescale#10225](timescale#10225) Add `config_merge` parameter to `alter_job()` for merging `jsonb` into the existing job configuration * [timescale#10226](timescale#10226) Add `recompress_unordered` columnstore policy option * [timescale#10231](timescale#10231) Use `regclass` for storing relation reference in chunk table * [timescale#10237](timescale#10237) Add helper functions for decoding hypertable status * [timescale#10240](timescale#10240) Add the `tsdb.direct_compress` storage parameter that allows enabling direct compress for a given hypertable independent of global settings * [timescale#10266](timescale#10266) Add `max_batches` to `compact_chunk()` * [timescale#10299](timescale#10299) Add `continuous_aggs_tenant_tracking` and `hypertable_cagg_settings` catalogs **Bugfixes** * [timescale#10013](timescale#10013) Make ownership error messages on continuous aggregates consistent * [timescale#10052](timescale#10052) Result of `MIN` / `MAX` aggregate functions in columnar aggregation pipeline possibly inconsistent with plain PostgreSQL result * [timescale#10071](timescale#10071) Prune the real-time branch of hierarchical continuous aggregates at any nesting depth * [timescale#10143](timescale#10143) Fix division by zero when planning `time_bucket` with zero width * [timescale#10199](timescale#10199) Fix `initial_start` handling in `build_job_info` * [timescale#10213](timescale#10213) Cache sort pathkeys per hypertable * [timescale#10221](timescale#10221) Fix incremental refresh skipping the last bucket * [timescale#10278](timescale#10278) Drop `job_errors` view in `bgw_job_stat_history` migration * [timescale#10280](timescale#10280) `RETURNING` clause returned no rows for `INSERT` using direct compress * [timescale#10281](timescale#10281) Disable direct compress when the destination table has an exclusion constraint so the constraint is still enforced * [timescale#10282](timescale#10282) Only count directly compressed rows toward the command tag when the `INSERT` sets it * [timescale#10286](timescale#10286) Propagate `VACUUM` on a chunk to the compressed relation when running on the chunk directly * [timescale#10302](timescale#10302) Fix useless-join removal and self-join elimination for hypertables * [timescale#10313](timescale#10313) Allow running `ALTER EXTENSION timescaledb UPDATE` inside a transaction block * [timescale#10315](timescale#10315) Fix overlap detection with running max * [timescale#10324](timescale#10324) Fix stale index entries after `rebuild_sparse_index()` on compressed chunks **GUCs** * `timescaledb.enable_hypertable_expansion_for_dml`: allow using the optimized TimescaleDB hypertable expansion code for `UPDATE` and `DELETE` instead of the generic PostgreSQL inheritance hierarchy expansion. On by default. **Thanks** * @FrancescEthon and @ManuelEthon for reporting an issue with incremental refresh skipping the last bucket * @h0rn3t for reporting a problem with `VACUUM` not propagating to the * @igor2x for reporting an issue with locking during DML statements on hypertables compressed relation * @MaximeEthon for reporting an issue with prepared statement parameters in DML decompression * @proddata for reporting a problem when upgrading from 2.15.3 to 2.28.2 * @tureba for reporting and fixing stale sparse-index entries after rebuild * @viniciusrsouza for reporting an issue with hierarchical continuous aggregates
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What type of bug is this?
Data corruption / wrong query results
What subsystems and features are affected?
Compression (sparse indexes / columnstore),
rebuild_sparse_index()What happened?
After changing a hypertable's
compress_indexsetting and callingrebuild_sparse_index()(or recompressing) on an already-compressed chunk, the btree index that TimescaleDB maintains on the compressed table's sparse index metadata columns (segmentby column plus_ts_meta_v2_first_time/_ts_meta_v2_last_time, and any configured minmax/bloom columns) can end up pointing at superseded, dead tuples instead of the row's new location.Once that happens, any query plan that uses an index scan or bitmap scan against the compressed table (chosen by the planner, or forced via
enable_seqscan = off) silently returns fewer rows than exist, or the wrong rows, for the affected compressed batch — while a sequential scan over the same table returns the correct data. This is a genuine data-correctness bug: two scan strategies over the same committed data return different results.The bug does not always surface immediately after the metadata rewrite, which is why it can look intermittent: PostgreSQL's HOT-chain "redirect" mechanism transparently follows a stale index entry to the tuple's current location, as long as the whole update chain stays on the same heap page. The wrong results only become visible once that redirect chain is broken — either by ordinary autovacuum/pruning activity, or immediately, whenever the metadata rewrite doesn't fit back onto its original page and the tuple must relocate.
Root cause
populate_sparse_index_columns()intsl/src/compression/recompress.crewrites each compressed batch's sparse-index metadata columns in place:The comment's premise is stale: the sparse index metadata columns are covered by a btree index (
<chunk>_compressed_<segmentby>__ts_meta_v2_first_time___idx, covering the segmentby column,_ts_meta_v2_first_time,_ts_meta_v2_last_time, plus any minmax/bloom columns configured viacompress_index).update_indexesis computed correctly bysimple_table_tuple_update()/heap_update()on every call, but the result is discarded — no new index entry is ever inserted for a non-HOT update, leaving the old entry (now pointing at a dead tuple) as the only index entry for that row.How can we reproduce the bug?
This reproduces deterministically on stock PostgreSQL — no special extension or vendor build required, and confirmed on PostgreSQL 16, 17, and 18. The key is to make each compressed row wide enough that the sparse-index metadata rewrite can't fit back onto its original heap page, forcing a non-HOT update:
(
<compressed_table>is the chunk's internal compressed relation, found via_timescaledb_catalog.compression_settings.compress_relidfor the chunk.)Actual output observed on a clean vanilla PostgreSQL 17.9 build:
vs. the sequential-scan ground truth, which correctly returns
d5.Root-cause confirmation (via
pageinspect): the btree index still contains entries pointing at the row's pre-rebuild ctid; the row's actual current location (after the metadata rewrite forced it onto a different heap page) has no corresponding index entry at all.The fix
populate_sparse_index_columns()now opens the compressed table's indexes once (CatalogOpenIndexes) and inserts a new index entry after each update whenever it wasn't HOT-eligible, using theTU_UpdateIndexesresult the code already computed but previously discarded (ts_catalog_index_insert(), TimescaleDB's own vendored copy of PostgreSQL's staticCatalogIndexInsert()) — mirroring the same open-index/update/insert-index-entry sequence PostgreSQL's ownCatalogTupleUpdate()uses for catalog tuples.This also required switching from
simple_table_tuple_update()tosimple_heap_update(): the former funnels the update through aTupleTableSlotand materializes a throwaway copy of the tuple insideheapam_tuple_update(), so the caller's ownHeapTupleis never updated with the tuple's real post-update location or HOT status — using it to drivets_catalog_index_insert()silently inserted nothing.simple_heap_update()operates directly on the passedHeapTuple, exactly asCatalogTupleUpdate()relies on, and is consistent with the rest of this function, which already assumes a plain heap-backed relation (heap_modify_tuple,heap_deform_tuple).Added a regression check in
rebuild_sparse_index.sqlthat forces an index/bitmap scan after thecompress_indexdrop-and-restore dance in Test 10 and asserts the per-device batch counts match a sequential scan; this fails against unpatchedmainand passes with the fix.Verified against clean source builds of vanilla PostgreSQL 16.14, 17.9, and 18.4: compiles warning-free, and the full
compress*/recompress*/*sparse*TSL regression suite (51 tests) passes on all three.